在 Day 20 完成架構盤點後,我們發現目前的測試覆蓋還不夠完善。今天我們將建立完整的測試框架,使用 Node.js 內建測試執行器搭配 TypeScript,為 Kyo-System 後端服務建立可靠的測試基礎。
從 Node.js 18 開始,官方提供了內建的測試執行器(node:test),相比傳統的 Jest 或 Mocha,它有以下優勢:
// 1. 零依賴 - 無需額外安裝測試框架
// 2. 原生支援 - 與 Node.js 緊密整合
// 3. ESM 友善 - 完美支援 ES Modules
// 4. TypeScript 相容 - 搭配 tsx 或 ts-node 即可
// 5. 快速執行 - 啟動速度比 Jest 快 3-5 倍
與其他框架對比:
| 特性 | Node.js Test | Jest | Vitest | 
|---|---|---|---|
| 安裝大小 | 0 KB (內建) | ~30 MB | ~15 MB | 
| 啟動時間 | < 100ms | ~2-3s | ~500ms | 
| ESM 支援 | ✅ 原生 | ⚠️ 需配置 | ✅ 原生 | 
| TypeScript | 搭配 ts-node | 需 ts-jest | 內建支援 | 
| Watch 模式 | ✅ --watch | ✅ | ✅ | 
// apps/kyo-otp-service/package.json
{
  "name": "kyo-otp-service",
  "type": "module",
  "scripts": {
    "test": "node --test",
    "test:watch": "node --test --watch",
    "test:coverage": "node --test --experimental-test-coverage",
    "pretest": "tsc -p tsconfig.json"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.5.0"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES2022",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*", "test/**/*"],
  "exclude": ["node_modules", "dist"]
}
apps/kyo-otp-service/
├── src/
│   ├── app.ts
│   ├── routes/
│   └── middleware/
│
├── test/
│   ├── setup.ts                    # 測試環境設定
│   ├── helpers.ts                  # 測試輔助函數
│   │
│   ├── unit/                       # 單元測試
│   │   ├── services/
│   │   │   ├── otp.test.ts
│   │   │   └── sms.test.ts
│   │   └── utils/
│   │       └── validation.test.ts
│   │
│   ├── integration/                # 整合測試
│   │   ├── api/
│   │   │   ├── otp-send.test.ts
│   │   │   └── auth.test.ts
│   │   └── database/
│   │       └── tenant.test.ts
│   │
│   └── e2e/                        # 端對端測試
│       └── otp-flow.test.ts
│
└── package.json
// test/setup.ts
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
// 設定測試環境變數
process.env.NODE_ENV = 'test';
process.env.PORT = '0'; // 使用隨機 port
process.env.REDIS_URL = process.env.REDIS_TEST_URL || 'redis://localhost:6379/15';
// 全域清理函數
export const globalSetup = async () => {
  console.log('🧪 Setting up test environment...');
  // 可在這裡初始化測試資料庫連線等
};
export const globalTeardown = async () => {
  console.log('🧹 Cleaning up test environment...');
  // 清理測試資料
};
// 導出常用的測試工具
export { test, describe, before, after };
export { strict as assert } from 'node:assert';
// test/helpers.ts
import { buildApp } from '../src/app.js';
import type { FastifyInstance } from 'fastify';
/**
 * 建立測試用 Fastify 實例
 */
export async function createTestApp(): Promise<FastifyInstance> {
  const app = await buildApp();
  await app.ready();
  return app;
}
/**
 * 產生測試用 JWT Token
 */
export function generateTestToken(payload: {
  userId: string;
  gymId: string;
  email: string;
}): string {
  // 使用實際的 JWT 服務
  const { createToken } = await import('@kyong/kyo-core/auth/auth-service.js');
  return createToken(payload);
}
/**
 * 清理 Redis 測試資料
 */
export async function cleanupRedis() {
  const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
  const redis = getRedisClient();
  const keys = await redis.keys('test:*');
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}
/**
 * 產生隨機測試資料
 */
export const fixtures = {
  phone: () => `09${Math.random().toString().slice(2, 10)}`,
  email: () => `test-${Date.now()}@example.com`,
  gymId: () => `gym-test-${Date.now()}`,
  otpCode: () => Math.floor(100000 + Math.random() * 900000).toString(),
};
// test/unit/services/otp.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createOtpServiceFromEnv } from '@kyong/kyo-core';
import { cleanupRedis, fixtures } from '../../helpers.js';
describe('OTP Service Unit Tests', () => {
  const otpService = createOtpServiceFromEnv();
  before(async () => {
    await cleanupRedis();
  });
  after(async () => {
    await cleanupRedis();
  });
  test('應該成功發送 OTP', async () => {
    const phone = fixtures.phone();
    const result = await otpService.send({
      phone,
      templateId: 1,
    });
    assert.ok(result.success, 'OTP 發送應該成功');
    assert.ok(result.msgId, '應該返回訊息 ID');
    assert.equal(typeof result.msgId, 'string');
  });
  test('應該拒絕無效的手機號碼', async () => {
    await assert.rejects(
      async () => {
        await otpService.send({
          phone: '123',  // 無效號碼
          templateId: 1,
        });
      },
      {
        name: 'KyoError',
        message: /Invalid phone number/,
      },
      '應該拋出無效手機號碼錯誤'
    );
  });
  test('應該驗證正確的 OTP 碼', async () => {
    const phone = fixtures.phone();
    // 先發送 OTP
    await otpService.send({ phone, templateId: 1 });
    // 從 Redis 獲取實際的 OTP 碼(測試環境)
    const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
    const redis = getRedisClient();
    const code = await redis.get(`otp:${phone}`);
    assert.ok(code, '應該能從 Redis 獲取 OTP 碼');
    // 驗證
    const result = await otpService.verify({ phone, code: code! });
    assert.ok(result.valid, 'OTP 驗證應該成功');
  });
  test('應該拒絕錯誤的 OTP 碼', async () => {
    const phone = fixtures.phone();
    const result = await otpService.verify({
      phone,
      code: '000000',  // 錯誤的碼
    });
    assert.equal(result.valid, false, '錯誤的 OTP 應該驗證失敗');
    assert.equal(result.reason, 'invalid_code');
  });
  test('應該在 OTP 過期後拒絕驗證', async (t) => {
    // 使用 mock timer 控制時間
    const clock = t.mock.timers.enable({ apis: ['Date'] });
    const phone = fixtures.phone();
    await otpService.send({ phone, templateId: 1 });
    // 快轉 6 分鐘(OTP 有效期 5 分鐘)
    clock.tick(6 * 60 * 1000);
    const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
    const redis = getRedisClient();
    const code = await redis.get(`otp:${phone}`);
    const result = await otpService.verify({ phone, code: code || '123456' });
    assert.equal(result.valid, false);
    assert.equal(result.reason, 'expired');
  });
});
// test/unit/schemas/validation.test.ts
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { OtpSendSchema, OtpVerifySchema } from '@kyong/kyo-types/schemas.js';
describe('Zod Schema Validation Tests', () => {
  describe('OtpSendSchema', () => {
    test('應該接受有效的 OTP 發送請求', () => {
      const validData = {
        phone: '0987654321',
        templateId: 1,
      };
      const result = OtpSendSchema.safeParse(validData);
      assert.ok(result.success, '有效資料應該通過驗證');
      assert.deepEqual(result.data, validData);
    });
    test('應該拒絕無效的手機號碼格式', () => {
      const invalidData = {
        phone: '123',  // 太短
        templateId: 1,
      };
      const result = OtpSendSchema.safeParse(invalidData);
      assert.equal(result.success, false, '應該驗證失敗');
      if (!result.success) {
        assert.ok(result.error.issues.some(i => i.path[0] === 'phone'));
      }
    });
    test('應該接受選填的 templateId', () => {
      const dataWithoutTemplate = {
        phone: '0987654321',
      };
      const result = OtpSendSchema.safeParse(dataWithoutTemplate);
      assert.ok(result.success, 'templateId 是選填的');
    });
  });
  describe('OtpVerifySchema', () => {
    test('應該驗證 6 位數字的 OTP 碼', () => {
      const validData = {
        phone: '0987654321',
        code: '123456',
      };
      const result = OtpVerifySchema.safeParse(validData);
      assert.ok(result.success);
    });
    test('應該拒絕非 6 位數的 OTP 碼', () => {
      const invalidData = {
        phone: '0987654321',
        code: '12345',  // 只有 5 位
      };
      const result = OtpVerifySchema.safeParse(invalidData);
      assert.equal(result.success, false);
    });
  });
});
// test/unit/services/rate-limiter.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { RateLimiter } from '@kyong/kyo-core/rateLimiter.js';
import { cleanupRedis } from '../../helpers.js';
describe('Rate Limiter Unit Tests', () => {
  const limiter = new RateLimiter({
    points: 5,           // 5 次
    duration: 60,        // 每 60 秒
    keyPrefix: 'test:rl:',
  });
  before(async () => {
    await cleanupRedis();
  });
  after(async () => {
    await cleanupRedis();
  });
  test('應該允許在限制內的請求', async () => {
    const key = 'user:123';
    for (let i = 0; i < 5; i++) {
      const result = await limiter.consume(key);
      assert.ok(result, `第 ${i + 1} 次請求應該被允許`);
    }
  });
  test('應該阻擋超過限制的請求', async () => {
    const key = 'user:456';
    // 消耗掉所有配額
    for (let i = 0; i < 5; i++) {
      await limiter.consume(key);
    }
    // 第 6 次應該被拒絕
    await assert.rejects(
      async () => {
        await limiter.consume(key);
      },
      {
        name: 'Error',
        message: /Rate limit exceeded/,
      }
    );
  });
  test('應該在時間窗口後重置', async (t) => {
    const clock = t.mock.timers.enable({ apis: ['Date'] });
    const key = 'user:789';
    // 消耗配額
    for (let i = 0; i < 5; i++) {
      await limiter.consume(key);
    }
    // 快轉 61 秒
    clock.tick(61 * 1000);
    // 應該可以再次使用
    const result = await limiter.consume(key);
    assert.ok(result, '時間窗口後應該重置配額');
  });
});
// test/integration/api/otp-send.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestApp, cleanupRedis, fixtures, generateTestToken } from '../../helpers.js';
describe('OTP Send API Integration Tests', () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;
  let authToken: string;
  before(async () => {
    app = await createTestApp();
    authToken = generateTestToken({
      userId: 'test-user-1',
      gymId: fixtures.gymId(),
      email: fixtures.email(),
    });
    await cleanupRedis();
  });
  after(async () => {
    await app.close();
    await cleanupRedis();
  });
  test('POST /api/otp/send - 應該成功發送 OTP', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        phone: fixtures.phone(),
        templateId: 1,
      },
    });
    assert.equal(response.statusCode, 202, '應該返回 202 Accepted');
    const body = JSON.parse(response.body);
    assert.ok(body.success);
    assert.ok(body.msgId);
  });
  test('POST /api/otp/send - 應該拒絕未認證的請求', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      payload: {
        phone: fixtures.phone(),
      },
    });
    assert.equal(response.statusCode, 401, '應該返回 401 Unauthorized');
  });
  test('POST /api/otp/send - 應該驗證請求格式', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        phone: '123',  // 無效格式
      },
    });
    assert.equal(response.statusCode, 400, '應該返回 400 Bad Request');
    const body = JSON.parse(response.body);
    assert.ok(body.error);
  });
  test('POST /api/otp/send - 應該執行速率限制', async () => {
    const phone = fixtures.phone();
    // 快速發送多次請求
    const requests = Array(6).fill(null).map(() =>
      app.inject({
        method: 'POST',
        url: '/api/otp/send',
        headers: {
          authorization: `Bearer ${authToken}`,
        },
        payload: { phone },
      })
    );
    const responses = await Promise.all(requests);
    // 應該有請求被限流
    const rateLimited = responses.some(r => r.statusCode === 429);
    assert.ok(rateLimited, '應該觸發速率限制');
  });
});
// test/e2e/otp-flow.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestApp, cleanupRedis, fixtures, generateTestToken } from '../helpers.js';
describe('OTP End-to-End Flow', () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;
  let authToken: string;
  before(async () => {
    app = await createTestApp();
    authToken = generateTestToken({
      userId: 'e2e-user',
      gymId: 'e2e-gym',
      email: 'e2e@example.com',
    });
    await cleanupRedis();
  });
  after(async () => {
    await app.close();
    await cleanupRedis();
  });
  test('完整的 OTP 發送與驗證流程', async () => {
    const phone = fixtures.phone();
    // 1. 發送 OTP
    const sendResponse = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: { phone },
    });
    assert.equal(sendResponse.statusCode, 202);
    const sendBody = JSON.parse(sendResponse.body);
    assert.ok(sendBody.success);
    // 2. 從 Redis 獲取 OTP 碼(測試環境)
    const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
    const redis = getRedisClient();
    const code = await redis.get(`otp:${phone}`);
    assert.ok(code, '應該能從 Redis 獲取 OTP');
    // 3. 驗證 OTP
    const verifyResponse = await app.inject({
      method: 'POST',
      url: '/api/otp/verify',
      payload: {
        phone,
        code: code!,
      },
    });
    assert.equal(verifyResponse.statusCode, 200);
    const verifyBody = JSON.parse(verifyResponse.body);
    assert.ok(verifyBody.valid, 'OTP 應該驗證成功');
    // 4. 再次驗證應該失敗(一次性使用)
    const retryResponse = await app.inject({
      method: 'POST',
      url: '/api/otp/verify',
      payload: {
        phone,
        code: code!,
      },
    });
    const retryBody = JSON.parse(retryResponse.body);
    assert.equal(retryBody.valid, false, '已使用的 OTP 不應再次驗證成功');
  });
});
# 執行所有測試
pnpm test
# Watch 模式(開發時使用)
pnpm test:watch
# 產生覆蓋率報告
pnpm test:coverage
# 只執行特定測試檔案
node --test test/unit/services/otp.test.ts
# 執行特定目錄的測試
node --test test/integration/**/*.test.ts
$ pnpm test
✔ OTP Service Unit Tests > 應該成功發送 OTP (145.2ms)
✔ OTP Service Unit Tests > 應該拒絕無效的手機號碼 (12.4ms)
✔ OTP Service Unit Tests > 應該驗證正確的 OTP 碼 (89.3ms)
✔ OTP Service Unit Tests > 應該拒絕錯誤的 OTP 碼 (45.6ms)
✔ OTP Service Unit Tests > 應該在 OTP 過期後拒絕驗證 (23.1ms)
✔ Zod Schema Validation Tests > OtpSendSchema > 應該接受有效的 OTP 發送請求 (5.2ms)
✔ Zod Schema Validation Tests > OtpSendSchema > 應該拒絕無效的手機號碼格式 (3.8ms)
✔ Rate Limiter Unit Tests > 應該允許在限制內的請求 (67.4ms)
✔ Rate Limiter Unit Tests > 應該阻擋超過限制的請求 (34.2ms)
✔ OTP Send API Integration Tests > 應該成功發送 OTP (234.5ms)
✔ OTP Send API Integration Tests > 應該拒絕未認證的請求 (45.3ms)
✔ OTP Send API Integration Tests > 應該執行速率限制 (567.8ms)
✔ OTP End-to-End Flow > 完整的 OTP 發送與驗證流程 (423.6ms)
ℹ tests 13
ℹ suites 5
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1847.2
// 設定覆蓋率目標
{
  "test": {
    "coverage": {
      "thresholds": {
        "lines": 80,      // 行覆蓋率 80%
        "functions": 80,  // 函數覆蓋率 80%
        "branches": 75,   // 分支覆蓋率 75%
        "statements": 80  // 語句覆蓋率 80%
      }
    }
  }
}
# .github/workflows/test.yml
name: Tests
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      - name: Build packages
        run: pnpm run build
      - name: Run tests
        run: pnpm test
        env:
          REDIS_TEST_URL: redis://localhost:6379/15
      - name: Generate coverage
        run: pnpm test:coverage
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json
我們建立了完整的測試框架:
✅ 測試環境設定
✅ 單元測試
✅ 整合測試
✅ E2E 測試
✅ CI/CD 整合
明天我們將繼續完善測試,並加入: